今天繼續談談跟測試有關的東西。隨著時間過去,測試會變得更加複雜,難以維護,功能的每一次變更也要花額外的時間與心力去修正舊的測試。
如今我們已經完成了許多測試,那就要來看看這些測試好不好維護,也就是測試的可維護性。下面會提出一些個人在寫出一個好維護的測試時會注意的地方。
開發時把方法設定成 private 或是 protected 一般都是有原因的,通常是為了不把實作行為暴露給外部,又或者是混淆程式碼等手法。
如果測試一段 private 的程式碼,我們所測試的內容實際上是某個 class 的一個內部細節,這個細節是動態的,有可能因為一次重構而發生變化。在重構時,就算這隻程式對外的功能不變,也會因為所測試的內部細節已經改變而讓測試失敗。
所以在測試時應該始終關注在公開的方法上。
可以換一個角度想:這些私有的方法不會獨立存在,仔細追蹤一定會有一個公開的方法呼叫他,又或者是被其他私有方法調用。所以,所有的私有方法都是某一個實作的環節,而這些實作最終都會對應的某些公開的方法或是 api 上。
如果一個 private 方法需要被測試,那麼他也應該是公開的、靜態的方法,或至少是一個 internal 的方法。如果有其必要性,也可以把這些內容抽出來獨立成一個 class ,並針對這個 class 再寫一個測試程式。
這也是現在大家如此推崇 TDD 的一個原因,因為 TDD 可以讓你始終關注在測試公開方法上,細節則會拆分成各個私有方法。
包含邏輯的測試一般會有以下的語法:
通常包含邏輯的測試不會只驗證一件事情,同時,因為增加了許多邏輯也讓程式變得難以閱讀。
由於寫出這些測試的很有可能是原本完成這些邏輯的開發人員,他們有可能對需求或是邏輯有錯誤的認知,所以這些有邏輯的測試可能也重複了產品的邏輯程式碼,讓測試中帶著無法預知的 bug 。
那還有一些測試程式,裡面有只為測試服務的邏輯,這種情況又如何呢?其實也應該盡量避免寫出有測試邏輯的測試程式,因為他們也會帶有出現 bug 的風險,而且這些 bug 更難以追蹤,因為我們通常覺得出問題都是在產品程式上而不會是測試本身的邏輯。
如果這是必要之惡,那也需要寫一支測試這些測試邏輯的程式。而實際上,這些有測試邏輯的程式也不是單元測試,而是整合測試。
我們先來看看一個程式:
fun chooseTheMaxValue(first: Int, second: Int, third: Int): Int {
// 從三個數中找出最大數
// 假設了一個故意錯誤的邏輯
if (second > third) {
return third
}
if (first > third) {
return third
}
......
return 正確的最大數
}
......
@Test
fun testSumAndDivide() {
assertThat(chooseTheMaxValue(6, 2, 1), `is`(6))
assertThat(chooseTheMaxValue(1, 6, 2), `is`(6))
assertThat(chooseTheMaxValue(1, 2, 6), `is`(6))
}
上面的測試包含了多個測試,實際上測試了三個不同的子功能。
我們為了方便寫了這樣的測試,這會有什麼問題呢?當測試失敗時,會在第一個錯誤的地方跳出,而不會繼續執行,也就是說其他的測試根本沒有被測試到,遺漏了其他有可能也錯誤的驗證。
有時我們的確在某個驗證出問題時不需要再關心其他的驗證,但像上面的的範例,每一個驗證都是對某個結果所進行的測試,即使一個驗證錯誤了,我們還是會想要知道其他驗證的結果。
這時比較常見的解決方式是將每個驗證結果各自獨立成一個測試,另外,在一個測試中測試太多關注點也會也會衍生出以下的問題。
理想上一個好的測試是可以讓人易於理解的,這時測試的命名就十分重要,因為它可以讓人在第一時間就知道這個測試的內容及目的是什麼。
如同前面所寫的,當測試帶有太多的資訊,會使測試變得難懂且複雜,讓後續的開發人員必須要去閱讀實際的程式碼才能理解測試的內容,無形中增加了許多維護成本。
討論測試的命名可以將內容簡單分成兩類:方法的命名及變數的命名
一個測試的方法名稱通常要包含以下資訊:
讓我們改寫上面的第一個驗證:
fun selectMaxValue(first: Int, second: Int, third: Int): Int {
......
return 正確的最大數
}
......
@Test
fun selectMaxValue_whenInputMaxValueIntoFirstParam_thenReturnMaxValue() {
val result = chooseTheMaxValue(6, 2, 1)
assertThat(result, `is`(6))
}
現在的測試方法能夠告訴我們許多更明確的訊息了。
上面的範例還有許多不足之處,我們再舉另一個 API 測試的例子:
@Test
fun BadNamingTest() {
val service = APIService()
val result = service.getSomeData("access token")
assertThat(result, `is`(200))
}
這個範例的問題是出現了一個神奇數字 200 ,很顯然 200 代表了某個有意義的的值,這個值是一個異常還是有效的結果我們無從得知。這時可以這麼處理:
throw AccessTokenExpiredException()
@Test
fun RefactoringTest() {
val ACCESS_TOKEN = "access token"
val API_RESULT_OK = 200
val service = APIService()
val result = service.getSomeData(ACCESS_TOKEN)
assertThat(result, `is`(API_RESULT_OK))
}
以上的內容都是為了要增加測試的可讀性,程式碼易於閱讀可以讓開發人員快速的理解程式的組成及功能的起始點。
今天就到這裡,關於可維護性還有許多細節,如果未來有時間會在我的 Medium 上繼續討論。